{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<style>div.container { width: 100% }</style>\n",
    "<img style=\"float:left;  vertical-align:text-bottom;\" height=\"65\" width=\"172\" src=\"../assets/PyViz_logo_wm_line.png\" />\n",
    "<div style=\"float:right; vertical-align:text-bottom;\"><h2>Tutorial 11. Streaming Data</h2></div>"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "\"Streaming data\" is data that is continuously generated, often by some external source like a remote website, a measuring device, or a simulator. This kind of data is common for financial time series, web server logs, scientific applications, and many other situations. We have seen how to visualize any data output by a callable in the [Live Data](06-Live_Data.ipynb) user guide and we have also seen how to use the HoloViews stream system to push events in the user guide sections [Responding to Events](11-Responding_to_Events.ipynb) and [Custom Interactivity](12-Custom_Interactivity.ipynb).\n",
    "\n",
    "This user guide shows a third way of building an interactive plot, using ``DynamicMap`` and streams.  Here, instead of pushing plot metadata (such as zoom ranges, user triggered events such as ``Tap`` and so on) to a ``DynamicMap`` callback, the underlying data in the visualized elements are updated directly using a HoloViews ``Stream``.\n",
    "\n",
    "In particular, we will show how the HoloViews ``Pipe`` and ``Buffer`` streams can be used to work with streaming data sources without having to fetch or generate the data from inside the ``DynamicMap`` callable. Apart from simply setting element data from outside a ``DynamicMap``, we will also explore ways of working with streaming data coordinated by the optional separate [``streamz``](http://matthewrocklin.com/blog/work/2017/10/16/streaming-dataframes-1) library from Matt Rocklin, which can make building complex streaming pipelines much simpler.\n",
    "\n",
    "<div style=\"margin: 10px\">\n",
    "<a href=\"http://holoviews.org\"><img style=\"margin:8px; display:inline; object-fit:scale-down; max-height:150px\" src=\"../assets/holoviews.png\"/></a>\n",
    "<a href=\"http://bokeh.pydata.org\"><img style=\"margin:8px; display:inline; object-fit:scale-down; max-height:150px\" src=\"../assets/bokeh.png\"/></a>\n",
    "<a href=\"http://numpy.org\"><img style=\"margin:8px; display:inline; object-fit:scale-down; max-height:150px\" src=\"../assets/numpy.png\"/></a>\n",
    "<a href=\"http://pandas.pydata.org\"><img style=\"margin:8px; display:inline; object-fit:scale-down; max-height:130px\" src=\"../assets/pandas.png\"/></a>\n",
    "</div>\n",
    "\n",
    "Note that this page demonstrates functionality that requires a live, running Python server.  When exported to a static HTML page as on the pyviz.org website, you will only see a single plot.  When running this code as a Jupyter notebook, you should execute it cell by cell to see the effect of each operation in turn."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import time\n",
    "import numpy as np\n",
    "import pandas as pd\n",
    "import holoviews as hv\n",
    "\n",
    "from holoviews.streams import Pipe, Buffer\n",
    "\n",
    "import streamz\n",
    "import streamz.dataframe\n",
    "\n",
    "hv.extension('bokeh')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## ``Pipe``\n",
    "\n",
    "A ``Pipe`` allows data to be pushed into a DynamicMap callback to change a visualization, just like the streams in the [Responding to Events](./11-Responding_to_Events.ipynb) user guide were used to push changes to metadata that controlled the visualization. A ``Pipe`` can be used to push data of any type and make it available to a ``DynamicMap`` callback. Since all ``Element`` types accept ``data`` of various forms we can use ``Pipe`` to push data directly to the constructor of an ``Element`` through a DynamicMap.\n",
    "\n",
    "\n",
    "We can take advantage of the fact that most Elements can be instantiated without providing any data, so we declare the the ``Pipe`` with an empty list and declare the ``DynamicMap``, providing the pipe as a stream, which will dynamically update a ``VectorField`` :"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "pipe = Pipe([])\n",
    "vector_dmap = hv.DynamicMap(hv.VectorField, streams=[pipe])\n",
    "vector_dmap.redim.range(x=(-1, 1), y=(-1, 1))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Having set up this ``VectorField`` tied to a ``Pipe`` we can start pushing data to it varying the orientation of the VectorField:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "x,y  = np.mgrid[-10:11,-10:11] * 0.1\n",
    "sine_rings  = np.sin(x**2+y**2)*np.pi+np.pi\n",
    "exp_falloff = 1/np.exp((x**2+y**2)/8)\n",
    "\n",
    "for i in np.linspace(0, 1, 25):\n",
    "    time.sleep(0.1)\n",
    "    pipe.send([x,y,sine_rings*i, exp_falloff])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "This approach of using an element constructor directly does not allow you to use anything other than the default key and value dimensions. One simple workaround for this limitation is to use ``functools.partial`` as demonstrated in the **Controlling the length section** below.\n",
    "\n",
    "Since ``Pipe`` is completely general and the data can be any custom type, it provides a completely general mechanism to stream structured or unstructured data. Due to this generality, ``Pipe`` does not offer some of the more complex features and optimizations available when using the ``Buffer`` stream described in the next section."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## ``Buffer``\n",
    "\n",
    "While ``Pipe`` provides a general solution for piping arbitrary data to ``DynamicMap`` callback,  ``Buffer`` on the other hand provides a very powerful means of working with streaming tabular data, defined as pandas dataframes, arrays, or dictionaries of columns (as well as StreamingDataFrame, which we will cover later). ``Buffer`` automatically accumulates the last ``N`` rows of the tabular data, where ``N`` is defined by the ``length``.\n",
    "\n",
    "The ability to accumulate data allows performing operations on a recent history of data, while plotting backends (such as bokeh) can optimize plot updates by sending just the latest patch. This optimization works only if the ``data`` object held by the ``Buffer`` is identical to the plotted ``Element`` data, otherwise all the data will be updated as normal."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### A simple example: Brownian motion\n",
    "\n",
    "To initialize a ``Buffer`` we have to provide an example dataset which defines the columns and dtypes of the data we will be streaming. Next we define the ``length`` to keep the last 100 rows of data. If the data is a DataFrame we can specify whether we will also want to use the ``DataFrame`` ``index``. In this case we will simply define that we want to plot a ``DataFrame`` of 'x' and 'y' positions and a 'count' as ``Points`` and ``Curve`` elements:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "example = pd.DataFrame({'x': [], 'y': [], 'count': []}, columns=['x', 'y', 'count'])\n",
    "dfstream = Buffer(example, length=100, index=False)\n",
    "curve_dmap = hv.DynamicMap(hv.Curve, streams=[dfstream])\n",
    "point_dmap = hv.DynamicMap(hv.Points, streams=[dfstream])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "After applying some styling we will display an ``Overlay`` of the dynamic ``Curve`` and ``Points``"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%opts Points [color_index='count', xaxis=None, yaxis=None] (line_color='black', size=5)\n",
    "%%opts Curve (line_width=1, color='black')\n",
    "curve_dmap * point_dmap"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Now that we have set up the ``Buffer`` and defined a ``DynamicMap`` to plot the data we can start pushing data to it. We will define a simple function which simulates brownian motion by accumulating x, y positions. We can ``send`` data through the ``hv.streams.Buffer`` directly."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def gen_brownian():\n",
    "    x, y, count = 0, 0, 0\n",
    "    while True:\n",
    "        x += np.random.randn()\n",
    "        y += np.random.randn()\n",
    "        count += 1\n",
    "        yield pd.DataFrame([(x, y, count)], columns=['x', 'y', 'count'])\n",
    "\n",
    "brownian = gen_brownian()\n",
    "for i in range(200):\n",
    "    dfstream.send(next(brownian))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Finally, we can clear the data on the stream and plot using the ``clear`` method:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#dfstream.clear()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Using the Streamz library\n",
    "\n",
    "Now that we have discovered what ``Pipe`` and ``Buffer`` can do it's time to show how you can use them together with the ``streamz`` library. Although HoloViews does not depend on ``streamz`` and you can use the streaming functionality without needing to learn about ``streamz``, the two libraries work well together, allowing you to build pipelines to manage continuous streams of data. Streamz is easy to use for simple tasks, but also supports complex pipelines that involve branching, joining, flow control, feedback and more. Here we will mostly focus on connecting streamz output to ``Pipe`` and then ``Buffer`` so for more details about the streamz API, consult the [streamz documentation](https://streamz.readthedocs.io/en/latest/).\n",
    "\n",
    "#### Using ``streamz.Stream`` together with ``Pipe``\n",
    "\n",
    "Let's start with a fairly simple example:\n",
    "\n",
    "1. Declare a ``streamz.Stream`` and a ``Pipe`` object and connect them into a pipeline into which we can push data. \n",
    "2. Use a ``sliding_window`` of 20, which will first wait for 20 sets of stream updates to accumulate. At that point and for every subsequent update, it will apply ``pd.concat`` to combine the most recent 20 updates into a new dataframe.\n",
    "3. Use the ``sink`` method on the ``streamz.Stream`` to ``send`` the resulting collection of 20 updates to ``Pipe``.\n",
    "4. Declare a ``DynamicMap`` that takes the sliding window of concatenated DataFrames and displays it using a ``Scatter`` Element.\n",
    "5. Color the ``Scatter`` points by their 'count' and set a range, then display:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "point_source = streamz.Stream()\n",
    "pipe = Pipe(data=[])\n",
    "point_source.sliding_window(20).map(pd.concat).sink(pipe.send) # Connect streamz to the Pipe\n",
    "scatter_dmap = hv.DynamicMap(hv.Scatter, streams=[pipe])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "After set up our streaming pipeline we can again display it:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%opts Scatter [color_index='count', bgcolor='black']\n",
    "scatter_dmap.redim.range(y=(-4, 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "There is now a pipeline, but initially this plot will be empty, because no data has been sent to it. To see the plot update, let's use the ``emit`` method of ``streamz.Stream`` to send small chunks of random pandas ``DataFrame``s to our plot:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for i in range(100):\n",
    "    df = pd.DataFrame({'x': np.random.rand(100), 'y': np.random.randn(100), 'count': i},\n",
    "                      columns=['x', 'y', 'count'])\n",
    "    point_source.emit(df)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### Using StreamingDataFrame and StreamingSeries\n",
    "\n",
    "The streamz library (version 0.2) provides ``StreamingDataFrame`` and ``StreamingSeries`` as a powerful way to easily work with live sources of tabular data. This makes it perfectly suited to work with ``Buffer``. (In version 0.3 ``streamz`` has renamed the classes to remove ``Streaming``, so the code here would need updating to use with the later ``streamz`` version.) With the ``StreamingDataFrame`` we can easily stream data, apply computations such as cumulative and rolling statistics and then visualize the data with HoloViews.\n",
    "\n",
    "The ``streamz.dataframe`` module provides a ``Random`` utility that generates a ``StreamingDataFrame`` that emits random data with a certain frequency at a specified interval. The ``example`` attribute lets us see the structure and dtypes of the data we can expect:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "simple_sdf = streamz.dataframe.Random(freq='10ms', interval='100ms')\n",
    "print(simple_sdf.index)\n",
    "simple_sdf.example.dtypes"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Since the ``StreamingDataFrame`` provides a pandas-like API, we can specify operations on the data directly. In this example we subtract a fixed offset and then compute the cumulative sum, giving us a randomly drifting timeseries. We can then pass the x-values of this dataframe to the HoloViews ``Buffer`` and supply ``hv.Curve`` as the ``DynamicMap`` callback to stream the data into a HoloViews ``Curve`` (with the default key and value dimensions):"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%opts Curve [width=500 show_grid=True]\n",
    "sdf1 = (simple_sdf-0.5).cumsum()\n",
    "hv.DynamicMap(hv.Curve, streams=[Buffer(sdf1.x)])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "The ``Random`` StreamingDataFrame will asynchronously emit events, driving the visualization forward, until it is explicitly stopped, which we can do by calling the ``stop`` method."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "simple_sdf.stop()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### Making use of the ``StreamingDataFrame`` API\n",
    "\n",
    "So far we have only computed the cumulative sum, but the ``StreamingDataFrame`` actually has an extensive API that lets us run a broad range of streaming computations on our data. For example, let's apply a rolling mean to our x-values with a window of 500ms and overlay it on top of the 'raw' data:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "source_df = streamz.dataframe.Random(freq='5ms', interval='100ms')\n",
    "sdf2 = (source_df-0.5).cumsum()\n",
    "sdf2.tail()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "raw       = hv.DynamicMap(hv.Curve, streams=[Buffer(sdf2.x)]).relabel('raw')\n",
    "smooth    = hv.DynamicMap(hv.Curve, streams=[Buffer(sdf2.x.rolling('500ms').mean())]).relabel('smooth')\n",
    "\n",
    "raw * smooth.options(width=900,height=350)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#source_df.stop()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### Customizing elements with ``functools.partial``\n",
    "\n",
    "In this notebook we have avoided defining custom functions for ``DynamicMap`` by simply supplying the element class and using the element constructor instead. Although this works well for examples, it often won't generalize to real-life situations, because you don't have an opportunity to use anything other than the default dimensions. One simple way to get around this limitation is to use ``functools.partial``:\n",
    "\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from functools import partial"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Now you can now easily create an inline callable that creates an element with custom key and value dimensions by supplying them to ``partial`` in the form ``partial(hv.Element, kdims=[...], vdims=[...])``. In the next section, we will see an example of this pattern using ``hv.BoxWhisker``.\n",
    "\n",
    "#### Controlling the length\n",
    "\n",
    "By default the ``Buffer`` accumulates a ``length`` of 1000 samples. In many cases this may be excessive, but we can specify a shorter (or longer) length value to control how much history we accumulate, often depending on the element type.\n",
    "\n",
    "In the following example, a custom ``length`` is used together with a ``partial`` wrapping ``hv.BoxWhisker`` in order to display a cumulative sum generated from a stream of random dataframes:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "multi_source = streamz.dataframe.Random(freq='5ms', interval='100ms')\n",
    "sdf3 = (multi_source-0.5).cumsum()\n",
    "hv.DynamicMap(hv.Table, streams=[Buffer(sdf3.x, length=10)]) +\\\n",
    "hv.DynamicMap(partial(hv.BoxWhisker, kdims=[], vdims='x'), streams=[Buffer(sdf3.x, length=100)])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Here the given stream ``sdf3`` is being consumed by a table showing a short length (where only the items visible in the table need to be kept), along with a plot computing averages and variances over a longer length (100 items).\n",
    "\n",
    "#### Updating multiple cells\n",
    "\n",
    "Since a ``StreamingDataFrame`` will emit data until it is stopped, we can subscribe multiple plots across different cells to the same stream.  Here, let's add a ``Scatter`` plot of the same data stream as in the preceding cell:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "hv.DynamicMap(hv.Scatter, streams=[Buffer(sdf3.x)]).redim.label(x='value', index='time')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Here we let the ``Scatter`` elements use the column names from the supplied ``DataFrames`` which are relabelled using the ``redim`` method. Stopping the stream will now stop updates to all three of these DynamicMaps:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "multi_source.stop()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Operations over streaming data\n",
    "\n",
    "As we discovered above, the ``Buffer`` lets us set a ``length``, which defines how many rows we want to accumulate. We can use this to our advantage and apply an operation over this length window. In this example we declare a ``Dataset`` and then apply the ``histogram`` operation to compute a ``Histogram`` over the specified ``length`` window:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "hist_source = streamz.dataframe.Random(freq='5ms', interval='100ms')\n",
    "sdf4 = (hist_source-0.5).cumsum()\n",
    "\n",
    "dmap = hv.DynamicMap(hv.Dataset, streams=[Buffer(sdf4.x, length=500)])\n",
    "hv.operation.histogram(dmap, dimension='x')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "hist_source.stop()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### Datashading\n",
    "\n",
    "The same approach will also work for the datashader operation, letting us datashade the entire ``length`` window even if we make it very large (1 million samples in this case):"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%opts RGB [width=600]\n",
    "from holoviews.operation.datashader import datashade\n",
    "from bokeh.palettes import Blues8\n",
    "large_source = streamz.dataframe.Random(freq='100us', interval='200ms')\n",
    "sdf5 = (large_source-0.5).cumsum()\n",
    "\n",
    "dmap = hv.DynamicMap(hv.Curve, streams=[Buffer(sdf5.x, length=1000000)])\n",
    "datashade(dmap, streams=[hv.streams.PlotSize], normalization='linear', cmap=Blues8)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "large_source.stop()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Asynchronous updates using the tornado ``IOLoop``\n",
    "\n",
    "In most cases, instead of pushing updates manually from the same Python process, you'll want the object to update asynchronously as new data arrives. Since both Jupyter and Bokeh server run on [tornado](http://www.tornadoweb.org/en/stable/), we can use the tornado ``IOLoop`` in both cases to define a non-blocking co-routine that can push data to our stream whenever it is ready."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%opts Curve [width=600]\n",
    "from tornado.ioloop import PeriodicCallback\n",
    "\n",
    "count = 0\n",
    "buffer = Buffer(np.zeros((0, 2)), length=50)\n",
    "\n",
    "def f():\n",
    "    global count\n",
    "    count += 1\n",
    "    buffer.send(np.array([[count, np.random.rand()]]))\n",
    "    \n",
    "cb = PeriodicCallback(f, 100)\n",
    "cb.start()\n",
    "\n",
    "hv.DynamicMap(hv.Curve, streams=[buffer]).redim.range(y=(0, 1))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "cb.stop()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Here the plot should update 100 times as before, when you execute the notebook cell, but now via the Tornado IOLoop which will not block other interactions and work in the notebook.\n",
    "\n",
    "## Real examples\n",
    "\n",
    "Using the ``Pipe`` and ``Buffer`` streams we can create complex streaming plots very easily. In addition to the toy examples we presented in this guide it is worth looking at looking at some of the examples using real, live, streaming data.\n",
    "\n",
    "* The [streaming_psutil](http://holoviews.org/gallery/apps/bokeh/stream_psutil.html) bokeh app is one such example which display CPU and memory information using the ``psutil`` library  (install with ``pip install psutil`` or ``conda install psutil``)\n",
    "\n",
    "<img class=\"gif\" src=\"http://assets.holoviews.org/gifs/guides/user_guide/Streaming_Data/streamz9.gif\"></img>\n",
    "\n",
    "As you can see, streaming data works like streams in HoloViews in general, flexibly handling changes over time under either explicit control or governed by some external data source.\n",
    "\n",
    "The final sections of this tutorial will show how to bring all of the concepts covered so far into an interactive web app for working with large or small datasets, starting with an introduction to [Parameters and Widgets](./12_Parameters_and_Widgets.ipynb)."
   ]
  }
 ],
 "metadata": {
  "language_info": {
   "name": "python",
   "pygments_lexer": "ipython3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
